/*
 * Copyright 2002-2018 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.web.servlet.i18n;

import java.util.Locale;
import java.util.TimeZone;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.context.i18n.LocaleContext;
import org.springframework.context.i18n.SimpleLocaleContext;
import org.springframework.context.i18n.TimeZoneAwareLocaleContext;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.LocaleContextResolver;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.util.CookieGenerator;
import org.springframework.web.util.WebUtils;

/**
 * {@link LocaleResolver} implementation that uses a cookie sent back to the user
 * in case of a custom setting, with a fallback to the specified default locale
 * or the request's accept-header locale.
 *
 * <p>This is particularly useful for stateless applications without user sessions.
 * The cookie may optionally contain an associated time zone value as well;
 * alternatively, you may specify a default time zone.
 *
 * <p>Custom controllers can override the user's locale and time zone by calling
 * {@code #setLocale(Context)} on the resolver, e.g. responding to a locale change
 * request. As a more convenient alternative, consider using
 * {@link org.springframework.web.servlet.support.RequestContext#changeLocale}.
 *
 * @author Juergen Hoeller
 * @author Jean-Pierre Pawlak
 * @see #setDefaultLocale
 * @see #setDefaultTimeZone
 * @since 27.02.2003
 */
public class CookieLocaleResolver extends CookieGenerator implements LocaleContextResolver {

    /**
     * The name of the request attribute that holds the Locale.
     * <p>Only used for overriding a cookie value if the locale has been
     * changed in the course of the current request!
     * <p>Use {@code RequestContext(Utils).getLocale()}
     * to retrieve the current locale in controllers or views.
     *
     * @see org.springframework.web.servlet.support.RequestContext#getLocale
     * @see org.springframework.web.servlet.support.RequestContextUtils#getLocale
     */
    public static final String LOCALE_REQUEST_ATTRIBUTE_NAME = CookieLocaleResolver.class.getName() + ".LOCALE";

    /**
     * The name of the request attribute that holds the TimeZone.
     * <p>Only used for overriding a cookie value if the locale has been
     * changed in the course of the current request!
     * <p>Use {@code RequestContext(Utils).getTimeZone()}
     * to retrieve the current time zone in controllers or views.
     *
     * @see org.springframework.web.servlet.support.RequestContext#getTimeZone
     * @see org.springframework.web.servlet.support.RequestContextUtils#getTimeZone
     */
    public static final String TIME_ZONE_REQUEST_ATTRIBUTE_NAME = CookieLocaleResolver.class.getName() + ".TIME_ZONE";

    /**
     * The default cookie name used if none is explicitly set.
     */
    public static final String DEFAULT_COOKIE_NAME = CookieLocaleResolver.class.getName() + ".LOCALE";


    private boolean languageTagCompliant = true;

    @Nullable
    private Locale defaultLocale;

    @Nullable
    private TimeZone defaultTimeZone;


    /**
     * Create a new instance of the {@link CookieLocaleResolver} class
     * using the {@link #DEFAULT_COOKIE_NAME default cookie name}.
     */
    public CookieLocaleResolver() {
        setCookieName(DEFAULT_COOKIE_NAME);
    }


    /**
     * Specify whether this resolver's cookies should be compliant with BCP 47
     * language tags instead of Java's legacy locale specification format.
     * <p>The default is {@code true}, as of 5.1. Switch this to {@code false}
     * for rendering Java's legacy locale specification format. For parsing,
     * this resolver leniently accepts the legacy {@link Locale#toString}
     * format as well as BCP 47 language tags in any case.
     *
     * @see #parseLocaleValue(String)
     * @see #toLocaleValue(Locale)
     * @see Locale#forLanguageTag(String)
     * @see Locale#toLanguageTag()
     * @since 4.3
     */
    public void setLanguageTagCompliant(boolean languageTagCompliant) {
        this.languageTagCompliant = languageTagCompliant;
    }

    /**
     * Return whether this resolver's cookies should be compliant with BCP 47
     * language tags instead of Java's legacy locale specification format.
     *
     * @since 4.3
     */
    public boolean isLanguageTagCompliant() {
        return this.languageTagCompliant;
    }

    /**
     * Set a fixed Locale that this resolver will return if no cookie found.
     */
    public void setDefaultLocale(@Nullable Locale defaultLocale) {
        this.defaultLocale = defaultLocale;
    }

    /**
     * Return the fixed Locale that this resolver will return if no cookie found,
     * if any.
     */
    @Nullable
    protected Locale getDefaultLocale() {
        return this.defaultLocale;
    }

    /**
     * Set a fixed TimeZone that this resolver will return if no cookie found.
     *
     * @since 4.0
     */
    public void setDefaultTimeZone(@Nullable TimeZone defaultTimeZone) {
        this.defaultTimeZone = defaultTimeZone;
    }

    /**
     * Return the fixed TimeZone that this resolver will return if no cookie found,
     * if any.
     *
     * @since 4.0
     */
    @Nullable
    protected TimeZone getDefaultTimeZone() {
        return this.defaultTimeZone;
    }


    @Override
    public Locale resolveLocale(HttpServletRequest request) {
        parseLocaleCookieIfNecessary(request);
        return (Locale) request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME);
    }

    @Override
    public LocaleContext resolveLocaleContext(final HttpServletRequest request) {
        parseLocaleCookieIfNecessary(request);
        return new TimeZoneAwareLocaleContext() {
            @Override
            @Nullable
            public Locale getLocale() {
                return (Locale) request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME);
            }

            @Override
            @Nullable
            public TimeZone getTimeZone() {
                return (TimeZone) request.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME);
            }
        };
    }

    private void parseLocaleCookieIfNecessary(HttpServletRequest request) {
        if (request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME) == null) {
            Locale locale = null;
            TimeZone timeZone = null;

            // Retrieve and parse cookie value.
            String cookieName = getCookieName();
            if (cookieName != null) {
                Cookie cookie = WebUtils.getCookie(request, cookieName);
                if (cookie != null) {
                    String value = cookie.getValue();
                    String localePart = value;
                    String timeZonePart = null;
                    int separatorIndex = localePart.indexOf('/');
                    if (separatorIndex == -1) {
                        // Leniently accept older cookies separated by a space...
                        separatorIndex = localePart.indexOf(' ');
                    }
                    if (separatorIndex >= 0) {
                        localePart = value.substring(0, separatorIndex);
                        timeZonePart = value.substring(separatorIndex + 1);
                    }
                    try {
                        locale = (!"-".equals(localePart) ? parseLocaleValue(localePart) : null);
                        if (timeZonePart != null) {
                            timeZone = StringUtils.parseTimeZoneString(timeZonePart);
                        }
                    } catch (IllegalArgumentException ex) {
                        String cookieDescription = "invalid locale cookie '" + cookieName +
                                "': [" + value + "] due to: " + ex.getMessage();
                        if (request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) != null) {
                            // Error dispatch: ignore locale/timezone parse exceptions
                            if (logger.isDebugEnabled()) {
                                logger.debug("Ignoring " + cookieDescription);
                            }
                        } else {
                            throw new IllegalStateException("Encountered " + cookieDescription);
                        }
                    }
                    if (logger.isTraceEnabled()) {
                        logger.trace("Parsed cookie value [" + cookie.getValue() + "] into locale '" + locale +
                                "'" + (timeZone != null ? " and time zone '" + timeZone.getID() + "'" : ""));
                    }
                }
            }

            request.setAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME,
                    (locale != null ? locale : determineDefaultLocale(request)));
            request.setAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME,
                    (timeZone != null ? timeZone : determineDefaultTimeZone(request)));
        }
    }

    @Override
    public void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale) {
        setLocaleContext(request, response, (locale != null ? new SimpleLocaleContext(locale) : null));
    }

    @Override
    public void setLocaleContext(HttpServletRequest request, @Nullable HttpServletResponse response,
                                 @Nullable LocaleContext localeContext) {

        Assert.notNull(response, "HttpServletResponse is required for CookieLocaleResolver");

        Locale locale = null;
        TimeZone timeZone = null;
        if (localeContext != null) {
            locale = localeContext.getLocale();
            if (localeContext instanceof TimeZoneAwareLocaleContext) {
                timeZone = ((TimeZoneAwareLocaleContext) localeContext).getTimeZone();
            }
            addCookie(response,
                    (locale != null ? toLocaleValue(locale) : "-") + (timeZone != null ? '/' + timeZone.getID() : ""));
        } else {
            removeCookie(response);
        }
        request.setAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME,
                (locale != null ? locale : determineDefaultLocale(request)));
        request.setAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME,
                (timeZone != null ? timeZone : determineDefaultTimeZone(request)));
    }


    /**
     * Parse the given locale value coming from an incoming cookie.
     * <p>The default implementation calls {@link StringUtils#parseLocale(String)},
     * accepting the {@link Locale#toString} format as well as BCP 47 language tags.
     *
     * @param localeValue the locale value to parse
     * @return the corresponding {@code Locale} instance
     * @see StringUtils#parseLocale(String)
     * @since 4.3
     */
    @Nullable
    protected Locale parseLocaleValue(String localeValue) {
        return StringUtils.parseLocale(localeValue);
    }

    /**
     * Render the given locale as a text value for inclusion in a cookie.
     * <p>The default implementation calls {@link Locale#toString()}
     * or JDK 7's {@link Locale#toLanguageTag()}, depending on the
     * {@link #setLanguageTagCompliant "languageTagCompliant"} configuration property.
     *
     * @param locale the locale to stringify
     * @return a String representation for the given locale
     * @see #isLanguageTagCompliant()
     * @since 4.3
     */
    protected String toLocaleValue(Locale locale) {
        return (isLanguageTagCompliant() ? locale.toLanguageTag() : locale.toString());
    }

    /**
     * Determine the default locale for the given request,
     * Called if no locale cookie has been found.
     * <p>The default implementation returns the specified default locale,
     * if any, else falls back to the request's accept-header locale.
     *
     * @param request the request to resolve the locale for
     * @return the default locale (never {@code null})
     * @see #setDefaultLocale
     * @see javax.servlet.http.HttpServletRequest#getLocale()
     */
    @Nullable
    protected Locale determineDefaultLocale(HttpServletRequest request) {
        Locale defaultLocale = getDefaultLocale();
        if (defaultLocale == null) {
            defaultLocale = request.getLocale();
        }
        return defaultLocale;
    }

    /**
     * Determine the default time zone for the given request,
     * Called if no TimeZone cookie has been found.
     * <p>The default implementation returns the specified default time zone,
     * if any, or {@code null} otherwise.
     *
     * @param request the request to resolve the time zone for
     * @return the default time zone (or {@code null} if none defined)
     * @see #setDefaultTimeZone
     */
    @Nullable
    protected TimeZone determineDefaultTimeZone(HttpServletRequest request) {
        return getDefaultTimeZone();
    }

}
